package com.googlecode.mp4parser.stuff;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.mp4parser.Box;
import org.mp4parser.Container;
import org.mp4parser.IsoFile;
import org.mp4parser.boxes.UnknownBox;
import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox;
import org.mp4parser.boxes.apple.AppleItemListBox;
import org.mp4parser.boxes.apple.AppleNameBox;
import org.mp4parser.boxes.apple.Utf8AppleDataBox;
import org.mp4parser.boxes.iso14496.part12.*;
import org.mp4parser.boxes.microsoft.XtraBox;
import org.mp4parser.tools.Path;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;

/**
 * Added by marwatk 3/1/15
 */
public class MetaDataTool {
    public static final boolean DEBUG = true;
    public static final String WM_RATING_TAG = "WM/SharedUserRating";
    public static final int WM_RATING_VALS[] = {0, 1, 25, 50, 75, 99};
    public static final String WM_TAGS_TAG = "WM/Category";
    //http://stackoverflow.com/questions/3389348/parse-any-date-in-java
    private static final HashMap<String, String> DATE_FORMAT_REGEXPS = new HashMap<String, String>() {
        {
            put("^\\d{8}$", "yyyyMMdd");
            put("^\\d{1,2}-\\d{1,2}-\\d{4}$", "dd-MM-yyyy");
            put("^\\d{4}-\\d{1,2}-\\d{1,2}$", "yyyy-MM-dd");
            put("^\\d{1,2}/\\d{1,2}/\\d{4}$", "MM/dd/yyyy");
            put("^\\d{4}/\\d{1,2}/\\d{1,2}$", "yyyy/MM/dd");
            put("^\\d{1,2}\\s[a-z]{3}\\s\\d{4}$", "dd MMM yyyy");
            put("^\\d{1,2}\\s[a-z]{4,}\\s\\d{4}$", "dd MMMM yyyy");
            put("^\\d{12}$", "yyyyMMddHHmm");
            put("^\\d{8}\\s\\d{4}$", "yyyyMMdd HHmm");
            put("^\\d{1,2}-\\d{1,2}-\\d{4}\\s\\d{1,2}:\\d{2}$", "dd-MM-yyyy HH:mm");
            put("^\\d{4}-\\d{1,2}-\\d{1,2}\\s\\d{1,2}:\\d{2}$", "yyyy-MM-dd HH:mm");
            put("^\\d{1,2}/\\d{1,2}/\\d{4}\\s\\d{1,2}:\\d{2}$", "MM/dd/yyyy HH:mm");
            put("^\\d{4}/\\d{1,2}/\\d{1,2}\\s\\d{1,2}:\\d{2}$", "yyyy/MM/dd HH:mm");
            put("^\\d{1,2}\\s[a-z]{3}\\s\\d{4}\\s\\d{1,2}:\\d{2}$", "dd MMM yyyy HH:mm");
            put("^\\d{1,2}\\s[a-z]{4,}\\s\\d{4}\\s\\d{1,2}:\\d{2}$", "dd MMMM yyyy HH:mm");
            put("^\\d{14}$", "yyyyMMddHHmmss");
            put("^\\d{8}\\s\\d{6}$", "yyyyMMdd HHmmss");
            put("^\\d{1,2}-\\d{1,2}-\\d{4}\\s\\d{1,2}:\\d{2}:\\d{2}$", "dd-MM-yyyy HH:mm:ss");
            put("^\\d{4}-\\d{1,2}-\\d{1,2}\\s\\d{1,2}:\\d{2}:\\d{2}$", "yyyy-MM-dd HH:mm:ss");
            put("^\\d{1,2}/\\d{1,2}/\\d{4}\\s\\d{1,2}:\\d{2}:\\d{2}$", "MM/dd/yyyy HH:mm:ss");
            put("^\\d{4}/\\d{1,2}/\\d{1,2}\\s\\d{1,2}:\\d{2}:\\d{2}$", "yyyy/MM/dd HH:mm:ss");
            put("^\\d{1,2}\\s[a-z]{3}\\s\\d{4}\\s\\d{1,2}:\\d{2}:\\d{2}$", "dd MMM yyyy HH:mm:ss");
            put("^\\d{1,2}\\s[a-z]{4,}\\s\\d{4}\\s\\d{1,2}:\\d{2}:\\d{2}$", "dd MMMM yyyy HH:mm:ss");
        }
    };
    private long originalUserDataSize = 0;
    private XtraBox xtraBox;
    private UserDataBox userDataBox;
    private MetaBox metaBox;
    private IsoFile isoFile;

    public MetaDataTool(String path) throws IOException {

        //The source I copied this from created 2 new files, a temp file and a target file
        //I'm not sure this is necessary, but maybe when you make changes it's edited in-place?
        //Anyway, just to be safe I'm keeping it so no operations are done on original file
        File videoFile = new File(path);
        if (!videoFile.exists())
            throw new FileNotFoundException("File " + path + " not exists");

        if (!videoFile.canWrite())
            throw new IllegalStateException("No write permissions to file " + path);

        File tempFile = File.createTempFile("ChangeMetaData", "");
        FileUtils.copyFile(videoFile, tempFile);
        tempFile.deleteOnExit();

        isoFile = new IsoFile(tempFile.getAbsolutePath());
        userDataBox = Path.getPath(isoFile, "/moov/udta");
        if (userDataBox != null) {
            originalUserDataSize = userDataBox.getSize();
        }

    }

    public static void main(String[] args) {
        if (args.length != 7 && args.length != 1) {
            System.err.println("Usage: java -jar metaDatTool.jar <inputFile> <outputFile> <title> <createDate> <userRating> <; separated tags> <gps coordinates>");
            System.err.println("  Use * for any value to keep the existing value, use an empty value to delete the current value");
            System.err.println("  Example: java -jar metaDataTool.jar myFile.mp4 newFile.mp4 \"New Title\" \"*\" 5 \"myTag 1;myTag 2\" \"\"");
            System.err.println("  This would retitle it, leave the create date alone, set the rating to 5 stars, ");
            System.err.println("  replace any tags with 'myTag 1' and 'myTag 2' and delete the existing GPS coordinates");
            System.err.println("Other usage: java -jar metaDataToo.jar <inputFile>");
            System.err.println("  Prints a dump of all tags in the file");
            System.exit(1);
        }

        if (args.length == 1) {
            MetaDataTool mdt;
            try {
                mdt = new MetaDataTool(args[0]);
                mdt.dumpBoxes();
                System.exit(0);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        int i = 0;
        String inFile = args[i++];
        String outFile = args[i++];
        String title = args[i++];
        String createDate = args[i++];
        String userRating = args[i++];
        String tags = args[i++];
        String gpsCoords = args[i++];

        try {
            System.out.println("================= BEFORE ===================");
            MetaDataTool mdt = new MetaDataTool(inFile);
            mdt.dumpBoxes();
            if (!"*".equals(title)) {
                mdt.setTitle(title);
            }
            if (!"*".equals(createDate)) {
                Date inputDate = parseDate(createDate);
                mdt.setMediaCreateDate(inputDate);
                mdt.setMediaModificationDate(inputDate);
            }
            if (!"*".equals(userRating)) {
                if ("".equals(userRating)) {
                    mdt.removeWindowsMediaTag(WM_RATING_TAG);
                } else {
                    mdt.setWindowsMediaRating(Integer.valueOf(userRating));
                }
            }
            if (!"*".equals(tags)) {
                if ("".equals(tags)) {
                    mdt.removeWindowsMediaTag(WM_TAGS_TAG);
                } else {
                    String tagsAr[] = tags.split(";");
                    mdt.setWindowsMediaTags(tagsAr);
                }
            }
            if (!"*".equals(gpsCoords)) {
                mdt.setGpsCoordinates(gpsCoords);
            }
            mdt.writeMp4(outFile);
            if (DEBUG) {
                mdt = new MetaDataTool(outFile);
                System.out.println("================= AFTER ===================");
                mdt.dumpBoxes();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static String getIndentation(int indent) {
        char c[] = new char[indent];
        for (int i = 0; i < indent; i++) {
            c[i] = ' ';
        }
        return new String(c);
    }

    private static void dumpBoxes(Container container, int indent) {
        String meInd = getIndentation(indent);
        String subInd = getIndentation(indent + 2);
        System.out.println(meInd + container.getClass().getName());
        for (Box box : container.getBoxes()) {
            if (box instanceof Container) {
                dumpBoxes((Container) box, indent + 2);
            } else {
                try {
                    if (box instanceof UnknownBox) {
                        System.out.println(subInd + box.getClass().getName() + "[" + box.getSize() + "/" + box.getType() + "]:" + box.toString());
                    } else if (box instanceof Utf8AppleDataBox) {
                        System.out.println(subInd + box.getClass().getName() + ": " + box.getType() + ": " + box.toString() + ": " + ((Utf8AppleDataBox) box).getValue());
                    } else {
                        System.out.println(subInd + box.getClass().getName() + ": " + box.getType() + "[" + box.getSize() + "]: " + box.toString());
                    }
                } catch (Exception e) {
                    System.err.println("Error parsing " + box.getClass().getSimpleName() + " box: " + e);
                    e.printStackTrace(System.err);
                }
            }
        }
    }

    public static boolean needsOffsetCorrection(IsoFile isoFile) {

        if (Path.getPaths(isoFile, "mdat").size() > 1) {
            throw new RuntimeException("There might be the weird case that a file has two mdats. One before" +
                    " moov and one after moov. That would need special handling therefore I just throw an " +
                    "exception here. ");
        }

        if (Path.getPaths(isoFile, "moof").size() > 0) {
            throw new RuntimeException("Fragmented MP4 files need correction, too. (But I would need to look where)");
        }

        for (Box box : isoFile.getBoxes()) {
            if ("mdat".equals(box.getType())) {
                return false;
            }
            if ("moov".equals(box.getType())) {
                return true;
            }
        }
        throw new RuntimeException("Hmmm - shouldn't happen");
    }

    private static void correctChunkOffsets(IsoFile tempIsoFile, long correction) {
        List<SampleTableBox> sampleTableBoxes = Path.getPaths(tempIsoFile, "/moov[0]/trak/mdia[0]/minf[0]/stbl[0]");

        for (SampleTableBox sampleTableBox : sampleTableBoxes) {

            List<Box> stblChildren = new ArrayList<Box>(sampleTableBox.getBoxes());
            ChunkOffsetBox chunkOffsetBox = Path.getPath(sampleTableBox, "stco");
            if (chunkOffsetBox == null) {
                stblChildren.remove(Path.getPath(sampleTableBox, "co64"));
            }
            stblChildren.remove(chunkOffsetBox);

            assert chunkOffsetBox != null;
            long[] cOffsets = chunkOffsetBox.getChunkOffsets();
            for (int i = 0; i < cOffsets.length; i++) {
                cOffsets[i] += correction;
            }

            StaticChunkOffsetBox cob = new StaticChunkOffsetBox();
            cob.setChunkOffsets(cOffsets);
            stblChildren.add(cob);
            sampleTableBox.setBoxes(stblChildren);
        }
    }

    public static void deleteQuietly(File f) {
        try {
            f.delete();
        } catch (Exception ioe) {
            //ignore
        }
    }

    public static void closeQuietly(IsoFile input) {
        try {
            if (input != null) {
                input.close();
            }
        } catch (IOException ioe) {
            // ignore
        }
    }

    public static Box getBox(Container outer, String type) {
        List<Box> list = getBoxes(outer, new String[]{type});
        return list.get(0);
    }

    public static List<Box> getBoxes(Container outer, String types[], List<Box> list) {
        for (Box box : outer.getBoxes()) {
            for (String type : types) {
                if (box.getType().equals(type)) {
                    list.add(box);
                }
            }
            if (box instanceof Container) {
                getBoxes((Container) box, types, list);
            }
        }
        return list;
    }

    public static List<Box> getBoxes(Container outer, String types[]) {
        List<Box> list = new ArrayList<Box>();
        return getBoxes(outer, types, list);
    }

    public static String determineDateFormat(String dateString) {
        for (String regexp : DATE_FORMAT_REGEXPS.keySet()) {
            if (dateString.toLowerCase().matches(regexp)) {
                return DATE_FORMAT_REGEXPS.get(regexp);
            }
        }
        return null; // Unknown format.
    }

    public static Date parseDate(String dateString) throws ParseException {
        String formatString = determineDateFormat(dateString);
        if (formatString == null) {
            return null;
        }
        SimpleDateFormat sdf = new SimpleDateFormat(formatString);
        return sdf.parse(dateString);
    }

    public void setWindowsMediaRating(int rating) { //0-5
        if (rating < 0 || rating > 5) {
            throw new RuntimeException("Invalid rating, 0-5 only");
        }

        if (rating == 0) {
            removeWindowsMediaTag(WM_RATING_TAG);
        } else {
            setWindowsMediaLong(WM_RATING_TAG, WM_RATING_VALS[rating]);
        }
    }

    public void setWindowsMediaTags(String tags[]) {
        if (tags == null || tags.length == 0) {
            removeWindowsMediaTag(WM_TAGS_TAG);
        } else {
            setWindowsMediaStrings(WM_TAGS_TAG, tags);
        }
    }

    private void setMediaDate(Date date, boolean create) {
        List<Box> headers = getBoxes(isoFile, new String[]{MovieHeaderBox.TYPE, MediaHeaderBox.TYPE, TrackHeaderBox.TYPE});
        boolean set = false;
        for (Box header : headers) {
            if (header instanceof MediaHeaderBox) {
                set = true;
                if (create) {
                    ((MediaHeaderBox) header).setCreationTime(date);
                } else {
                    ((MediaHeaderBox) header).setModificationTime(date);
                }
            } else if (header instanceof MovieHeaderBox) {
                set = true;
                if (create) {
                    ((MovieHeaderBox) header).setCreationTime(date);
                } else {
                    ((MovieHeaderBox) header).setModificationTime(date);
                }
            } else if (header instanceof TrackHeaderBox) {
                set = true;
                if (create) {
                    ((TrackHeaderBox) header).setCreationTime(date);
                } else {
                    ((TrackHeaderBox) header).setModificationTime(date);
                }

            }
        }
        setWindowsMediaDate("WM/EncodingTime", date);
        if (!set) {
            throw new RuntimeException("Can't yet add MovieHeaderBox or MediaHeaderBox and none were preset to set create and/or modify date");
        }
    }

    public void setGpsCoordinates(String iso6709String) {
        AppleGPSCoordinatesBox coordBox = (AppleGPSCoordinatesBox) getBox(isoFile, AppleGPSCoordinatesBox.TYPE);
        if (coordBox == null) {
            UserDataBox udb = getUserDataBox();
            coordBox = new AppleGPSCoordinatesBox();
            udb.addBox(coordBox);
        }
        coordBox.setValue(iso6709String);
    }

    public void setMediaCreateDate(Date date) {
        setMediaDate(date, true);
    }

    public void setMediaModificationDate(Date date) {
        setMediaDate(date, false);
    }

    public void setTitle(String title) {
        AppleNameBox titleBox = (AppleNameBox) getBox(isoFile, AppleNameBox.TYPE);
        if (titleBox == null) {
            AppleItemListBox itemList = getItemListBox();
            titleBox = new AppleNameBox();
            itemList.addBox(titleBox);
        }
        titleBox.setValue(title);
    }

    private AppleItemListBox getItemListBox() {
        AppleItemListBox itemList = (AppleItemListBox) getBox(isoFile, AppleItemListBox.TYPE);
        if (itemList == null) {
            MetaBox mb = getMetaBox();
            itemList = new AppleItemListBox();
            mb.addBox(itemList);
        }
        return itemList;
    }

    @SuppressWarnings("deprecation")
    public void setMediaModificationDate(String date) {
        setMediaModificationDate(new Date(Date.parse(date))); //Deprecated, but also the easiest way to do this quickly
    }

    @SuppressWarnings("deprecation")
    public void setMediaCreateDate(String date) {
        try {
            setMediaCreateDate(new Date(Date.parse(date))); //Deprecated, but also the easiest way to do this quickly
        } catch (IllegalArgumentException e) {
            throw new RuntimeException("Unable to parse date '" + date + "'", e);
        }
    }

    public void setWindowsMediaDate(String tagName, Date dateVal) {
        XtraBox xb = getXtraBox();
        xb.setTagValue(tagName, dateVal);
    }

    public void setWindowsMediaLong(String tagName, long longVal) {
        XtraBox xb = getXtraBox();
        xb.setTagValue(tagName, longVal);
    }

    public void setWindowsMediaStrings(String tagName, String values[]) {
        XtraBox xb = getXtraBox();
        xb.setTagValues(tagName, values);
    }

    public void removeWindowsMediaTag(String tagName) {
        XtraBox xb = getXtraBox();
        xb.removeTag(tagName);
    }

    private UserDataBox getUserDataBox() {
        if (userDataBox == null) {
            userDataBox = new UserDataBox();
            isoFile.getMovieBox().addBox(userDataBox);
        }
        return userDataBox;
    }

    private MetaBox getMetaBox() {
        if (metaBox == null) {
            UserDataBox ud = getUserDataBox();
            metaBox = (MetaBox) getBox(ud, MetaBox.TYPE);
            if (metaBox == null) {
                metaBox = new MetaBox();
                ud.addBox(metaBox);
            }
        }
        return metaBox;
    }

    private XtraBox getXtraBox() {
        if (xtraBox == null) {
            UserDataBox ud = getUserDataBox(); //Create user data box if necessary
            xtraBox = (XtraBox) getBox(ud, XtraBox.TYPE);
            if (xtraBox == null) {
                xtraBox = new XtraBox();
                ud.addBox(xtraBox);
            }
        }
        return xtraBox;
    }

    public void writeMp4(String filename) throws IOException {
        long finalUserDataSize = 0;
        if (userDataBox != null) {
            finalUserDataSize = userDataBox.getSize();
        }
        if (needsOffsetCorrection(isoFile)) {
            correctChunkOffsets(isoFile, finalUserDataSize - originalUserDataSize);
        }
        FileOutputStream videoFileOutputStream = null;
        try {
            videoFileOutputStream = new FileOutputStream(filename);
            isoFile.getBox(videoFileOutputStream.getChannel());
        } finally {
            closeQuietly(isoFile);
            IOUtils.closeQuietly(videoFileOutputStream);
        }
    }

    public void dumpBoxes() {
        dumpBoxes(isoFile, 0);
    }

}
